<?php
namespace DRY\Module;

/**
 * DonReY Framework 2012 :: Server-side file storage
 * - Handles file uploads, directory iterating and listing (single or recursive)
 * - Organizes file storage using identifiers stored in a database (optional)
 *
 * @author Wavetrex <wavetrex@gmail.com>
 * @link http://www.donrey.net/Documentation/Structure/Modules/warehouse.html
 * @license [GPL v3] http://www.gnu.org/licenses/gpl-3.0.html
 * @version 1.0.dev
 * @package DonReY
 * @subpackage Module.Warehouse
 */
class Warehouse extends base
{
	// Exception/Error codes
	const
		EXC_UNKNOWN = 0
		;

	const
		Autoload_Directory = 'warehouse',
		Autoload_Prefix = 'DRY\Warehouse\Analyzer',
		Autoload_Suffix = 'analyzer'
		;

	/**
	 * finfo object
	 * @var \finfo
	 */
	protected static $filedetect;

	/**
	 * List of mime types for which analyzers have registered. If a mime type is not here, no special analysis is done.
	 * @var array
	 */
	protected $analyzer_map = array();

	/**
	 * List of upload destinations depending on analyser used
	 * @var array
	 */
	protected $paths_map = array();

	/**
	 * Module constructor, prepares analyser data
	 * @param array $alias
	 * @param SimpleXMLElement $config
	 */
	public function __construct($alias, $config)
	{
		parent::__construct($alias, $config);

		// one time initialization of finfo object (used to detect the types of uploads)
		if(!self::$filedetect instanceof \finfo) {
			self::$filedetect = new \finfo(FILEINFO_MIME_TYPE|FILEINFO_PRESERVE_ATIME);
		}

		$upaths = \DRY\core::assemble_Absolute_Paths($this-> CFG-> paths);
		$this-> paths_map['_default'] = $upaths['default'][0];

		// checks analyzers and builds a mime-type database to analyzer map
		if(isset($this-> CFG-> analysers))
			foreach($this-> CFG-> analysers-> children() as $analyzer) {
				$aName = $analyzer-> getName();

				$anayzer_class = self::Autoload_Prefix.'_'.ucfirst($aName);
				$types = $anayzer_class::register_Mime_Types();
				foreach($types as $mime)
					$this-> analyzer_map[$mime] = $aName;

				$this-> paths_map[$analyzer-> getName()] = $upaths[$aName][0];
			}

		// Database component is being used
		if(isset($this-> CFG-> database)) {
			$this['record']-> define_Record('_warehouse', array(
				'type'=> 'single',
				'table'=> (string)$this-> CFG-> database['table']?:'warehouse',
				'fields'=> array(
					'fullpath'=>array('db_type'=>'varchar(255) NOT NULL'),
					'name'=>array('db_type'=>'varchar(255) NOT NULL'),
					'ext'=>array('db_type'=>'varchar(7) NOT NULL'),
					'size'=>array('db_type'=>'int(10) unsigned NOT NULL'),
					'mtime'=>array('db_type'=>'int(10) unsigned NOT NULL'),
					'mimetype'=>array('db_type'=>'varchar(40) NOT NULL'),
					'analyzer'=>array('db_type'=>'varchar(15) DEFAULT NULL'),
					'metadata'=>array('db_type'=>'text'),
					'chash'=>array('db_type'=>'char(40) DEFAULT NULL'),
					'count_reads'=>array('db_type'=>'int(10) unsigned NOT NULL DEFAULT 0')
				)
			));
		}

	}

	/**
	 * Result of process_Uploads run
	 * @var array
	 */
	protected $analysis;

	private $database_store;

	/**
	 * Checks if there are any file uploads and processes them ( sends to proper location, generates file information data )
	 */
	public function &process_Uploads($database_store = false)
	{
		$this-> analysis = array();
		$this-> database_store = $database_store;
		// checks if there are any file uploads
		if(!empty($_FILES)) {

			$this-> uploads = array();
			foreach($_FILES as $key=> $file_pack)
				if(is_array($file_pack['error'])) { // array mode (multiple files under the same field name)

					$keymap = \array_walk_recursive_Create_KeyMap($file_pack['error']);
					// arranges the file upload objects in the same way as the incoming structure from HTTP POST, but groups together all data (instead of the crappy way $_FILES is organized)
					$c = 0;
					$container = &$this-> uploads[$key];
					array_walk_recursive($file_pack['error'], function($err_code) use (&$container, $file_pack, $keymap, &$c) {
						\create_recursive_array_With_KeyMap($container, $keymap[$c], new Uploaded_File(array(
							'name'=> \search_recursive_array_With_KeyMap($file_pack['name'], $keymap[$c]),
							'type'=> \search_recursive_array_With_KeyMap($file_pack['type'], $keymap[$c]),
							'tmp_name'=> \search_recursive_array_With_KeyMap($file_pack['tmp_name'], $keymap[$c]),
							'error'=> \search_recursive_array_With_KeyMap($file_pack['error'], $keymap[$c]),
							'size'=> \search_recursive_array_With_KeyMap($file_pack['size'], $keymap[$c])
						)));
						$c++;
					});

				} else {
					$this-> uploads[$key] = new Uploaded_File($file_pack);
				}

			// now uploads can be processed one by one (and sent to their final destination)
			array_walk_recursive($this-> uploads, array($this, "process_File_Upload"));
			return $this-> analysis;
		} else {
			$null = null;
			return $null;
		}
	}

	/**
	 * Processes a file upload (using the helper class DRY_Uploaded_File)
	 *
	 * @param \DRY\Module\Uploaded_File $file
	 */
	protected function process_File_Upload(Uploaded_File $file)
	{
		$file_cl = $file-> get_Current_Location();
		if($file_cl!==false) {
			$fileinfo = $this-> analyse_File($file_cl);

			if(isset($fileinfo['analyzer'])) {
				$destination = $this-> paths_map[$fileinfo['analyzer']];
				$path_CFG = $this-> CFG-> paths-> {$fileinfo['analyzer']};
			} else {
				$destination = $this-> paths_map['_default'];
				$path_CFG = $this-> CFG-> paths-> default;
			}
			// if file is to be moved into a hash-spread path, analyse the file's content
			if(isset($path_CFG['mode']) && $path_CFG['mode']=='contentHash') {
				$fileinfo['chash'] = sha1_file($fileinfo['fullpath']);
			}

			// which moving function to use (direct (one folder), split by contentHash, split by filename)
			switch($path_CFG['mode']) {
				case 'contentHash':
					$fileinfo['fullpath'] = $file-> move_ContentHash($destination, $fileinfo['chash'], (int)$path_CFG['hashDepth']);
					break;
				case 'direct':
				default:
					$fileinfo['fullpath'] = $file-> move_Direct($destination);
					break;
			}
			if($fileinfo['fullpath']!==false) {
				$fileinfo['fullpath'] = realpath($fileinfo['fullpath']);
				$fileinfo['name'] = $file-> get_Original_Name();
				$fileinfo['ext'] = (($p=strrpos($fileinfo['name'], '.'))!==false)?strtolower(substr($fileinfo['name'], $p+1)):'';
				if(isset($this-> CFG-> database) && $this-> database_store)
					$fileinfo['id'] = $this-> store_File_Data($fileinfo);
			} else {
				$fileinfo['error'] = $file-> get_Error();
			}
			$this-> analysis[] = $fileinfo;

		} else
			$this-> analysis[] = array(
				'name'=> $file-> get_Original_Name(),
				'error'=> $file-> get_Error()
			);
	}

	/**
	 * Studies a file and grabs some information about it.
	 * It can optionally do more analysis on certain file types
	 * @param string $filepath File to study
	 * @param bool $get_type Not needed here, ALL are "files" ( no link or dir, since this is an uploaded file )
	 * @return array
	 */
	public function analyse_File($filepath, $get_type = false)
	{
		$finfo = new \SplFileInfo($filepath);
		$filename = $finfo->getFilename();
		$fileinfo=array(
			'fullpath'=>realpath($filepath),
			'mtime'=>	$finfo->getMTime(),
//			'ctime'=>	$finfo->getCTime(),	// Not needed, mtime is sufficient ( file upload time )
			'size'=>	$finfo->getSize(),
		);
		if($get_type)
			$fileinfo['type'] = $finfo->getType();
		$fileinfo['mimetype'] = self::$filedetect-> file($fileinfo['fullpath']);

		// test if the uploaded file is an image and grab additional info about it
		if(array_key_exists($fileinfo['mimetype'], $this-> analyzer_map)) {
			$fileinfo['analyzer'] = $this-> analyzer_map[$fileinfo['mimetype']];
			$analyzer_class = self::Autoload_Prefix.'_'.ucfirst($fileinfo['analyzer']);
			new $analyzer_class($fileinfo, $this-> CFG-> analysers-> {$fileinfo['analyzer']});
		}

		return $fileinfo;
	}

	/**
	 * Database Component - Stores information about the uploaded file in the database, returns the insert_id()
	 * Can also be called from outside to add information to the $fileinfo before storing
	 * @param array $fileinfo
	 * @return int Entry ID
	 */
	public function store_File_Data($fileinfo)
	{
		// prepare "metadata" field
		if(isset($fileinfo['analyzer'])) {
			$key = '_'.$fileinfo['analyzer'];
			// Warning: some analysers might return binary/non-UTF-8 data ! In this case, json_encode will fail... TODO: Fix this somehow, or require analyzers to always return properly encoded data
			$fileinfo['metadata'] = json_encode($fileinfo[$key]);
			unset($fileinfo[$key]);
		}

		$wh_row = $this['record']-> factory_Record('_warehouse');
		$wh_row-> import($fileinfo);
		return $wh_row-> id;
	}

	/**
	 * Reads the stored file data for an ID and decodes metadata (if it exists)
	 * @param int $id
	 * @param bool $increase Increase the count_reads value
	 */
	public function read_File_Data($id, $increase = false)
	{
		$wh_row = $this['record']-> factory_Record('_warehouse', $id);
		if($wh_row-> get_ID()) {
			$fileinfo = $wh_row-> export(true);
			if(!empty($fileinfo['analyzer'])) {
				$key = '_'.$fileinfo['analyzer'];
				$fileinfo[$key] = json_decode($fileinfo['metadata'], true);
				unset($fileinfo['metadata']);
			}
			if($increase)
				$wh_row-> count_reads = $wh_row-> count_reads + 1;
			return $fileinfo;
		} else
			return false; // error, bad ID
	}

	/**
	 * Reads the contents of a file, and also the stored file info. Can also generate the proper headers depending on file type, to generate a download or display inside
	 * @param int $id
	 * @param array $fileinfo
	 * @param bool $generate_headers
	 * @param bool $direct_output When generating headers, returns the file directly (readfile), instead of putting it into a string
	 * @param bool $force_download When generating headers, sets the headers for forced download for any file type (including images and text)
	 * @return string|bool Content of the file or "true" when $direct_output = true
	 */
	public function read_File_Content($id, &$fileinfo = null, $generate_headers = false, $direct_output = false, $force_download = false)
	{
		$fileinfo = $this-> read_File_Data($id, true);
		if($fileinfo) {
			if($generate_headers) {
				if($force_download) {
					header("Content-Type: application/force-download");
					header("Content-Disposition: attachment; filename=\"{$fileinfo['name']}\"");
					header("Content-Transfer-Encoding: binary");
				} else {
					header("Content-type: {$fileinfo['mimetype']}");
					header("Content-Disposition: inline; filename=\"{$fileinfo['name']}\"");
				}
				header("Content-length: ".(string)$fileinfo['size']);

				// cache control ( disable )
				header("Pragma: public");
				header("Pragma: no-cache");
				header("Expires: 0");
				header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
				header("Cache-Control: private", false); // required for certain browsers

				// Use cache?
//				header("Expires: ".gmdate("D, d M Y H:i:s", mktime(date("H")+2, date("i"), date("s"), date("m"), date("d"), date("Y")))." GMT");
//				header("Last-Modified: ".gmdate("D, d M Y H:i:s")." GMT");
				if($direct_output) {
					readfile($fileinfo['fullpath']);
					return true;
				}
			}
			return file_get_contents($fileinfo['fullpath']);
		} else
			return false; // error, bad ID
	}

	/**
	 * Removes a stored file reference, by it's ID. Searches if the same file (fullpath) is used in another reference. If not, removes the file itself as well.
	 *
	 * @param int $id
	 * @return int The number of references which still use the file, or 0 if the file was actually deleted. False if ID not found
	 */
	public function remove_File_Reference($id)
	{
		$wh_row = $this['record']-> factory_Record('_warehouse', $id);
		if($wh_row-> get_ID()) {
			$fullpath = (string)$wh_row-> fullpath;

			$q = $this['record']['queryfactory']-> select()
				-> table((string)$this-> CFG-> database['table'], 'count(id):count_refs')
				-> where('fullpath = ?', $fullpath);
			$res = $this['record']['connection']-> query($q)-> current();

			$wh_row-> destroy();
			if($res['count_refs']==1) {
				// if this was the only reference, remove the file itself from the warehouse
				unlink($fullpath);
				return 0;
			} else
				return $res['count_refs']-1;

		} else
			return false; // error, bad ID
	}

}

/**
 * Helper class for file uploads
 * @author Wavetrex <wavetrex@gmail.com>
 * @link http://www.donrey.net/Documentation/Structure/Modules/warehouse.html
 * @license [GPL v3] http://www.gnu.org/licenses/gpl-3.0.html
 * @version 1.0.dev
 * @package DonReY
 * @subpackage Module.Warehouse
 */
class Uploaded_File
{
	const UPLOAD_ERR_CANNOT_MOVE = 100;

	/**
	 * Indicates the state of the upload: 0 - Invalid upload, 1 = still into temporary location (where php uploaded it), 2 = moved to destination
	 * @var int
	 */
	private $completed = 0;

	private $name;

	private $mime_type;

	/**
	 *
	 * Temporary path + filename where php uploaded the file
	 * @var string
	 */
	private $tmp_name;

	/**
	 * Absolute path + filename where the file was moved (after one of the move methods)
	 * @var string
	 */
	private $final_name;

	private $size = 0;

	/**
	 * Error code for failed uploads
	 * @var int
	 */
	private $error;

	public function __construct($file_upload)
	{
		if($file_upload['error'] == UPLOAD_ERR_OK) {
			$this-> completed = 1;

			$this-> name = $file_upload['name'];
			$this-> mime_type = $file_upload['type'];
			$this-> tmp_name = $file_upload['tmp_name'];
			$this-> size = $file_upload['size'];
		} else
			$this-> error = $file_upload['error'];
	}

	/**
	 * Returns the absolute path that this file has (before and after being moved)
	 * @return string|false False if there was a problem with the upload
	 */
	public function get_Current_Location()
	{
		switch($this-> completed) {
			case 1:
				return $this-> tmp_name;
			case 2:
				return $this-> final_name;
		}
		// error, invalid upload
		return false;
	}

	/**
	 * Returns the original file name (how it was called in the user's browser). Final name might be different ( due to renaming or using it's hash )
	 * @return string
	 */
	public function get_Original_Name()
	{
		return $this-> name;
	}

	/**
	 * Returns upload error code (http://www.php.net/manual/en/features.file-upload.errors.php)
	 * @return int
	 */
	public function get_Error()
	{
		return $this-> error;
	}

	/**
	 * Move a file upload to a destination folder, optionally renaming it if a new name is specified
	 * @param string $dest_path
	 * @param string $rename
	 */
	public function move_Direct($dest_path, $rename = null)
	{
		// move only if file is in temporary location
		if($this-> completed==1) {
			// optionally rename
			$this-> final_name = $dest_path.'/'.(is_string($rename)?$rename:$this-> name);
			$r = @move_uploaded_file($this-> tmp_name, $this-> final_name);
			if($r==false) {
				$this-> error = self::UPLOAD_ERR_CANNOT_MOVE;
				return false;
			}
			$this-> completed = 2; // set as moved
			return $this-> final_name;
		} elseif($this-> completed==2)
			return $this-> final_name;
		return false;
	}

	/**
	 * Moves the uploaded file into hash-distributed folders, by hashing the content of the file.
	 * This is used to distribute large number of uploads in order to avoid directory overload. Also makes sure all files are unique.
	 *
	 * @param string $dest_path
	 * @return array Relevant information about this file, in order to locate it in the distributed folders
	 */
	public function move_ContentHash($dest_path, $hash, $hash_depth = 2)
	{
		// move only if file is in temporary location
		if($this-> completed==1) {
			for($c = 0; $c< $hash_depth; $c++)
				$dest_path.= '/'.$hash[$c];

			$ext = (($p=strrpos($this-> name, '.'))!==false)?strtolower(substr($this-> name, $p+1)):''; // might be empty if strrpos doesn't find a dot,
			$this-> final_name = $dest_path.'/'.$hash.'.'.$ext;

			// move if the destination doesn't already exist
	   		if(!file_exists($this-> final_name)) {
    			// path doesn't exist, create it first, before moving the file
	   			if(!is_dir($dest_path)) {
	   				mkdir($dest_path, 0777, true);
	   			}

	   			$r = @move_uploaded_file($this-> tmp_name, $this-> final_name);
				if($r==false) {
					$this-> error = self::UPLOAD_ERR_CANNOT_MOVE;
					return false;
				}
	   		}
	   		$this-> completed = 2; // set as moved
	   		return $this-> final_name;
		} elseif($this-> completed==2)
			return $this-> final_name;
		return false;
	}
}

namespace DRY\Warehouse;

/**
 * Analyzer required methods
 *
 * @author Wavetrex <wavetrex@gmail.com>
 * @link http://www.donrey.net/Documentation/Structure/Modules/warehouse.html
 * @license [GPL v3] http://www.gnu.org/licenses/gpl-3.0.html
 * @version 1.0.dev
 * @package DonReY
 * @subpackage Module.Warehouse
 */
interface Analyzer
{
	/**
	 * Studies a file which is presumed to be of a certain type
	 * @param array $fileinfo
	 * @param \SimpleXMLPlus $CFG
	 */
	public function __construct(&$fileinfo, $CFG);
}